Skip to content

feat: TypeScript DX pass — strict-safe tools/view/ask + typed agent DI#685

Open
blove wants to merge 33 commits into
mainfrom
claude/typescript-dx-pass
Open

feat: TypeScript DX pass — strict-safe tools/view/ask + typed agent DI#685
blove wants to merge 33 commits into
mainfrom
claude/typescript-dx-pass

Conversation

@blove

@blove blove commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Summary

A comprehensive TypeScript DX pass on the @threadplane/* public surface, fixing the developer-facing type defects surfaced by the published-stack audit. The goal: hero primitives that infer correctly under strict: true, link schemas to handler args / component inputs, carry agent state through DI, and read cleanly on hover — validated against the cockpit + canonical examples as forcing functions.

The headline bug

tools(map: Record<string, ClientToolDef>) took a non-generic union, so under strictFunctionTypes a typed action() was not assignabletools({ move: action(...) }) failed to compile for any real Angular CLI consumer. It was masked because every example compiled with strict: false.

Changes (5 workstreams)

  • WS1 — tools()/action(): tools<const M extends Record<string, ClientToolDef>>(map): Readonly<M> preserves per-key tool types and literal keys; action<S,R> now carries the handler return type. The ClientToolDef function member is a bivariant AnyFunctionToolDef (handler: (args: any)) so a precise FunctionToolDef<S,R> assigns in under strict and internal def.handler(args) calls still type-check.
  • WS2 — view()/ask(): strict-but-flexible schema↔component-input linkage (ComponentInputs/CompatibleProps/AcceptComponent). The model-filled props are checked against the component's Angular signal inputs at the call site; ViewProps<typeof schema> derives input types from a schema. A real mismatch (typo prop, wrong type, unrelated component) is a compile error.
  • WS3 — agent typing through DI: Agent<TState = Record<string,unknown>> + AgentWithHistory<TState> (default keeps everything non-breaking); createAgentRef<TState>() typed DI handle; provideAgent(ref, cfg) / injectAgent(ref) overloads in both the LangGraph and AG-UI adapters, so state types flow without per-call-site restatement.
  • WS4 — hovers + discoverability: internal Prettify<T> for clean quick-info; re-exports StandardSchema*, ToolArgs, ViewProps, AnyFunctionToolDef, AgentRef, createAgentRef from @threadplane/chat; @param/@returns/@example JSDoc on the hero exports.
  • WS5 — regression gate: hand-rolled Equal/Expect *.type-spec.ts files compiled under dedicated strict: true tsconfigs (type-tests nx target) in chat, langgraph, ag-ui.

Plus docs: the langgraph guides were migrated off the now-removed bare-generic injectAgent<T>() to the createAgentRef pattern.

Validation (examples as forcing functions)

  • Tier 1 — strict: true + typed-API migration: cockpit/ag-ui/client-tools, cockpit/langgraph/client-tools, and the canonical examples/ag-ui itinerary. Each builds green under full strict; a deliberately-injected schema↔component mismatch was confirmed to fail the strict build (the linkage has teeth).
  • Tier 2 — build-net: all 41 cockpit + example Angular apps build green, proving the Agent<TState> default contains the genericization ripple. Plus createAgentRef migrations in examples/chat + two cockpit apps.
  • Tier 3 — runtime smoke (fixture replay, no keys): examples/ag-ui e2e 21/21 including day_card view render, move_stop panel mutation, and the clear_day ask chain (the NG0950 path); examples/chat e2e green (one known streaming flake, passes in isolation).
  • Tier 4 — type-test + unit gate: type-tests green (chat/langgraph/ag-ui); lib test + lint green (0 errors).

Final whole-implementation review: no blocking issues; the variance soundness, view/ask linkage, and agent genericization were all adversarially verified.

Design spec and TDD plan are under docs/superpowers/. No backwards-compatibility constraint (pre-1.0); flat named exports kept.

🤖 Generated with Claude Code

blove and others added 22 commits June 18, 2026 08:44
Five-workstream plan to fix the @threadplane public-surface DX defects the
audit found: the critical strict-mode tools() rejection + key/value widening,
absent view/ask schema<->component linkage, agent generics not flowing through
DI, action return type, hover readability, and JSDoc. Core type machinery for
WS1 (variance-safe tools()) and WS2 (strict-but-flexible view/ask) verified
compiling under strict:true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ions

Add a four-tier validation strategy that uses the cockpit client-tools
examples and the canonical langgraph + ag-ui examples as forcing functions:
Tier 1 migrates the client-tools apps + canonical examples/ag-ui to the typed
APIs AND flips them to strict:true (the flag that hid the original bug);
Tier 2 builds all ~35 provideAgent/injectAgent apps as a non-breaking net for
the Agent<TState> genericization; Tier 3 live-smokes the client-tools +
canonical apps; Tier 4 keeps the unit type-spec gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
18 tasks across the five workstreams + the four-tier example validation
(forcing functions). Each lib change is gated by a strict:true type-spec;
the cockpit client-tools apps + canonical examples/ag-ui are flipped to
strict:true and migrated to the typed APIs. Also corrects the spec's WS1
variance sketch (any-param bivariant member, verified to support internal
handler calls).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rget)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…iant AnyFunctionToolDef)

Adds a precise `FunctionToolDef<S,R>` for authoring (carries schema + return type)
and a bivariant `AnyFunctionToolDef` as the union member in `ClientToolDef`, so typed
`action()` results are assignable into the registry under `strictFunctionTypes`.
Updates `executeFunctionTool` to accept `AnyFunctionToolDef`; exports `AnyFunctionToolDef`
from the public index.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…key + literal-key types

`action<S,R>()` now infers the handler return type R and carries it in
`FunctionToolDef<S,R>`. `tools<const M>()` is generic over the map so
literal keys and per-entry tool types survive into the registry type.
WS1 type-spec in tools.type-spec.ts covers arg-inference, return-type
inference, union assignability, literal keys, and per-key type preservation;
all assertions pass under strict + strictFunctionTypes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sk linkage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nent input linkage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ult Record<string,unknown>)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…c-api export

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…state through DI

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Ref overloads

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…Props/AnyFunctionToolDef

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…drop removed bare-generic injectAgent<T>()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…k (forcing function)

Flip strict: false → strict: true in the example app's tsconfig (the flag
that previously masked function-type variance bugs). The app was already
using the new generic action/view/ask/tools API, so no call-site changes
were needed; the build passed clean immediately.

Migrate the view/ask components to anchor their input types to the shared
schemas via ViewProps<typeof schema>, so a schema change is a compile error
on the component rather than a silent runtime mismatch. Extract schemas to
schemas.ts so both the registry call site and the components reference the
same schema object.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…w/ask + createAgentRef typed state (forcing function)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ols + createAgentRef (forcing function)

- Flip tsconfig.json to strict:true (build green immediately — no fallout)
- Add ItineraryState interface + ITINERARY_AGENT createAgentRef<ItineraryState> in client-tools.ts
- Wire provideAgent(ITINERARY_AGENT, …) in app.config.ts; injectAgent(ITINERARY_AGENT) in AgUiShell — removes state cast in submit wrapper
- Co-locate DAY_CARD_SCHEMA with DayCardComponent; anchor inputs via ViewProps<typeof DAY_CARD_SCHEMA> — the headline forcing-function proof that compile-time view/ask linkage guards the NG0950 class of bug
- Export CLEAR_DAY_SCHEMA from client-tools for symmetry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rations

Migrate three apps to the typed-agent `createAgentRef` DI pattern as Tier-2
forcing functions proving the typed path works across app types:

- cockpit/langgraph/streaming/angular: STREAMING_AGENT ref, StreamingState
- cockpit/chat/messages/angular: MESSAGES_AGENT ref, MessagesState
- examples/chat/angular: DEMO_AGENT_REF ref, DemoState (model/reasoning_effort/gen_ui_mode)

Each app gets a new agent-ref.ts, provideAgent(ref, config), injectAgent(ref),
and a typed read exercising state assignability. All three nx builds pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pass

# Conflicts:
#	examples/chat/angular/src/app/shell/demo-shell.component.ts
…istantId, drop dead link) + document CompatibleProps optional→required edge

Final-review follow-ups: the inject-agent API page showed provideAgent configs
missing the required assistantId (throws at runtime) and linked to a
non-existent create-agent-ref page. Also document the one CompatibleProps
soundness edge (optional schema prop vs required input) — caught by the runtime
readiness gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
threadplane Ready Ready Preview, Comment Jun 19, 2026 3:02pm

Request Review

@blove blove enabled auto-merge (squash) June 18, 2026 18:56
blove and others added 3 commits June 18, 2026 11:57
…gentRef/ToolArgs/ViewProps/AnyFunctionToolDef) + adapter ref overloads

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pass

# Conflicts:
#	libs/chat/project.json
#	libs/chat/src/public-api.ts
#	libs/chat/src/testing/type-assert.ts
#	libs/chat/tsconfig.type-tests.json
…ng e2e

#685 replaced the raw failure text with friendly AGENT_ERROR_MESSAGES copy
("Can't reach the server. Check your connection and try again."), which no
longer contains "fail"/"error". Mirror the chat example's assertion so the
ag-ui error-handling spec asserts the actual classified copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pass

# Conflicts:
#	examples/ag-ui/angular/src/app/app.config.ts
@claude

claude Bot commented Jun 19, 2026

Copy link
Copy Markdown

Claude encountered an error after 4m 53s —— View job


PR Review — TypeScript DX Pass

  • Gather context and understand PR scope
  • Read CLAUDE.md for repo guidelines
  • Review core type changes (tools/action, view/ask, agent DI)
  • Check for correctness bugs and broken behavior
  • Check TypeScript type-safety and unsafe casts
  • Check Angular/RxJS pitfalls
  • Check public API regressions
  • Check test coverage
  • Post findings

This is a well-structured PR. The variance reasoning (AnyFunctionToolDef bivariance hatch, AcceptComponent conditional type, ComponentInputs<C> with any for invariant InputSignal) is sound. Four issues are worth addressing before merge.


1. Unnecessary as never cast in execute.ts

libs/chat/src/lib/client-tools/execute.ts:29

const value = await def.handler((v as { value: unknown }).value as never);

def.handler is typed (args: any) => unknown | Promise<unknown>any accepts unknown directly. The as never is not needed and is actively misleading: never is the bottom type (no value can be this), and casting a runtime-validated value to it papers over the safety of the validation. The as { value: unknown } cast on v is necessary (discriminant narrowing), but drop the as never:

const value = await def.handler((v as { value: unknown }).value);

Fix this →


2. Duplicate isAgentRef guard in both adapters

libs/langgraph/src/lib/agent.provider.ts:90 and libs/ag-ui/src/lib/provide-agent.ts:46 both define the identical duck-typed guard:

function isAgentRef<T>(x: unknown): x is AgentRef<T> {
  return typeof x === 'object' && x !== null && 'token' in x;
}

This is harmless but creates a silent divergence risk: if AgentRef shape ever changes (e.g. gains a second field that should be checked), only one copy might be updated. The guard should live in @threadplane/chat alongside AgentRef and be re-exported, or at minimum the duplication should be acknowledged with a comment.

Additionally: any AgentConfig with a token property would be misidentified as an AgentRef. Given AgentConfig has no token field today, this is safe — but fragile.


3. Multiple AgentRefs in the same injector → silent last-wins collision

Both adapters share the same design: a single AGENT token per injector scope. The ref-based overload registers { provide: ref.token, useExisting: AGENT }, so the ref just aliases AGENT at that scope.

If a consumer tries to wire two refs at the same level:

providers: [
  provideAgent(TRIP_AGENT, { assistantId: 'trip' }),    // provides AGENT (trip)
  provideAgent(HOTEL_AGENT, { assistantId: 'hotel' }),  // overrides AGENT (hotel)!
]

Angular's DI uses last-provided wins for the same token. TRIP_AGENT.token would resolve to the hotel agent because both refs alias the same AGENT token — and the second provideAgent overwrote it. No error is thrown; the behavior is silent and wrong.

The workaround (scoping to separate component-level providers: []) works, but it's non-obvious. A JSDoc @remarks or a guard that throws when two refs are provided at the same scope would prevent a confusing debug session.


4. CompatibleProps optional-schema-prop → required-input gap is underdocumented at the call site

libs/chat/src/lib/client-tools/component-inputs.ts:39-41

The code comment explains this correctly internally, but the public JSDoc on view() and ask() in tools.ts doesn't mention it. A developer who uses z.string().optional() for a schema field and wires it to input.required<string>() will see the compile check pass but get a broken mount at runtime if the model skips the optional prop.

The existing comment should be surfaced in the @param component JSDoc or as a @remarks note on view/ask so it appears on hover. This is a DX issue on a "strict-safe DX" PR.


Minor observations (not blocking)

  • events$ Subject is never completed (libs/langgraph/src/lib/agent.fn.ts:587, libs/ag-ui/src/lib/to-agent.ts:89): The Subject<AgentEvent> outlives the agent. Consumers using subscribe() directly (rather than takeUntil) will have lingering subscriptions. This predates this PR but the new createAgentRef pattern increases the surface of consumers who might do this.

  • AgUiAgent<T> state typing is compile-time only: toAgent() always creates signal<Record<string, unknown>>({}) (libs/ag-ui/src/lib/to-agent.ts:87). The narrowed Signal<T> on the returned AgUiAgent<T> comes entirely from the as AgUiAgent<T> cast in injectAgent. This is an intentional DX trade-off and the structural match holds in practice — worth a brief note in the injectAgent JSDoc since it differs from the LangGraph adapter's approach.

  • Type-spec files call z from zod/v4: tools.type-spec.ts and view-ask.type-spec.ts import from zod/v4. If the project later supports Zod v3 as an input schema, the type-specs won't cover that path. Not an issue now; just noteworthy.


Summary

Ship-blocking: none — the core variance mechanics, linkage, and DI plumbing are correct.
Worth fixing before merge: issues 1 (trivial) and 3 (silent footgun).
Post-merge: issues 2 and 4 are real DX gaps on a PR whose explicit goal is DX quality.

@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 3m 24s —— View job


PR Review — TypeScript DX Pass

  • Gather context and understand PR scope
  • Read CLAUDE.md for repo guidelines
  • Read core changed files (execute.ts, tools.ts, component-inputs.ts, provide-agent, agent-ref)
  • Review type-safety and correctness
  • Check Angular/RxJS pitfalls
  • Check public API regressions
  • Post findings

This is a well-structured PR. The variance reasoning (AnyFunctionToolDef bivariance hatch, AcceptComponent conditional type, ComponentInputs<C> with any for invariant InputSignal) is sound. Four issues are worth addressing before merge.


1. Unnecessary as never cast in execute.ts

libs/chat/src/lib/client-tools/execute.ts:29

const value = await def.handler((v as { value: unknown }).value as never);

def.handler is typed (args: any) => unknown | Promise<unknown>any accepts unknown directly. The as never is not needed and is actively misleading: never is the bottom type (no value can be this), and casting a runtime-validated value to it papers over the safety of the validation. The as { value: unknown } cast on v is necessary (discriminant narrowing), but drop the as never:

const value = await def.handler((v as { value: unknown }).value);

Fix this →


2. Duplicate isAgentRef guard in both adapters

libs/langgraph/src/lib/agent.provider.ts:90 and libs/ag-ui/src/lib/provide-agent.ts:46 both define the identical duck-typed guard:

function isAgentRef<T>(x: unknown): x is AgentRef<T> {
  return typeof x === 'object' && x !== null && 'token' in x;
}

This is harmless but creates a silent divergence risk: if AgentRef shape ever changes (e.g. gains a second field that should be checked), only one copy might be updated. The guard should live in @threadplane/chat alongside AgentRef and be re-exported, or at minimum the duplication should be acknowledged with a comment.

Additionally: any object with a token property would be misidentified as an AgentRef. AgentConfig has no token field today, so it's safe — but fragile.


3. Multiple AgentRefs in the same injector → silent last-wins collision

Both adapters share the same design: a single AGENT token per injector scope. The ref-based overload registers { provide: ref.token, useExisting: AGENT }, so the ref just aliases AGENT at that scope.

If a consumer tries to wire two refs at the same level:

providers: [
  provideAgent(TRIP_AGENT, { assistantId: 'trip' }),    // provides AGENT (trip)
  provideAgent(HOTEL_AGENT, { assistantId: 'hotel' }),  // overrides AGENT (hotel)!
]

Angular's DI uses last-provided wins for the same token. TRIP_AGENT.token would resolve to the hotel agent because both refs alias the same AGENT token — and the second provideAgent overwrote it. No error is thrown; the behavior is silent and wrong.

The workaround (scoping to separate component-level providers: []) works, but it's non-obvious. A JSDoc @remarks or a guard that throws when two refs are provided at the same scope would prevent a confusing debug session.


4. CompatibleProps optional-schema-prop → required-input gap is underdocumented at the call site

libs/chat/src/lib/client-tools/component-inputs.ts:39-41

The code comment explains this correctly internally, but the public JSDoc on view() and ask() in tools.ts doesn't mention it. A developer who uses z.string().optional() for a schema field and wires it to input.required<string>() will see the compile check pass but get a broken mount at runtime if the model skips the optional prop.

The existing comment should be surfaced in the @param component JSDoc or as a @remarks note on view/ask so it appears on hover. This is a DX issue on a "strict-safe DX" PR.


Minor observations (not blocking)

  • events$ Subject is never completed (libs/langgraph/src/lib/agent.fn.ts:587, libs/ag-ui/src/lib/to-agent.ts:89): The Subject<AgentEvent> outlives the agent. Consumers using subscribe() directly (rather than takeUntil) will have lingering subscriptions. This predates this PR but the new createAgentRef pattern increases the surface of consumers who might do this.

  • AgUiAgent<T> state typing is compile-time only: toAgent() always creates signal<Record<string, unknown>>({}) (libs/ag-ui/src/lib/to-agent.ts:87). The narrowed Signal<T> on the returned AgUiAgent<T> comes entirely from the as AgUiAgent<T> cast in injectAgent. This is an intentional DX trade-off and the structural match holds in practice — worth a brief note in the injectAgent JSDoc since it differs from the LangGraph adapter's approach.

  • Token type mismatch in AG-UI injectAgent: ref.token has type InjectionToken<Agent<T>> (from agent-ref.ts), so inject(ref.token) returns Agent<T>. The cast to AgUiAgent<T> widens this to expose customEvents, clientTools, and subagents — structurally valid since toAgent() always returns these, but the as AgUiAgent<T> cast hides the gap in token typing.

  • Type-spec files import from zod/v4: tools.type-spec.ts and view-ask.type-spec.ts import from zod/v4. If the project later supports Zod v3 as an input schema, the type-specs won't cover that path. Not an issue now; just noteworthy.


Summary

Ship-blocking: none — the core variance mechanics, linkage, and DI plumbing are correct.
Worth fixing before merge: issues 1 (trivial) and 3 (silent footgun).
Post-merge: issues 2 and 4 are real DX gaps on a PR whose explicit goal is DX quality.

@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 3m 20s —— View job


PR Review — TypeScript DX Pass (Synchronize)

  • Gather context and understand PR scope
  • Read CLAUDE.md for repo guidelines
  • Review core changed files (execute.ts, tools.ts, component-inputs.ts, provide-agent, agent-ref)
  • Check correctness bugs and broken behaviour
  • Check TypeScript type-safety and unsafe casts
  • Check Angular/RxJS pitfalls
  • Check public API regressions
  • Post findings

The synchronize event is a merge of main into the branch (c37f011) — no new feature commits since the last review. The four issues identified in prior reviews are still open.

The variance reasoning (AnyFunctionToolDef bivariance hatch, AcceptComponent conditional, ComponentInputs<C> with any for invariant InputSignal) is sound. Core mechanics are correct. Issues below are the outstanding items.


1. Unnecessary as never cast — execute.ts:29

const value = await def.handler((v as { value: unknown }).value as never);

AnyFunctionToolDef.handler is typed (args: any) => unknown | Promise<unknown>. any accepts unknown directly — the as never cast is not needed and is actively misleading (never is the bottom type; no value can be it). Drop it:

const value = await def.handler((v as { value: unknown }).value);

The as { value: unknown } cast on v is necessary (discriminant narrowing under strictNullChecks: false); the inner as never is not.

Fix this →


2. Duplicate isAgentRef guard in both adapters

libs/langgraph/src/lib/agent.provider.ts:90 and libs/ag-ui/src/lib/provide-agent.ts:46 both define identical duck-typed guards:

function isAgentRef<T>(x: unknown): x is AgentRef<T> {
  return typeof x === 'object' && x !== null && 'token' in x;
}

If AgentRef shape ever gains a second required field, only one copy may be updated, causing silent misidentification. The guard belongs in @threadplane/chat alongside AgentRef and should be re-exported (or at minimum, co-located comments should note the intentional duplication).

Also: any object with a token property would pass this guard. AgentConfig has no token field today — safe but fragile.


3. Multiple AgentRefs in the same injector → silent last-wins collision (worth a JSDoc @remarks)

Both adapters use a single AGENT token per injector scope, with ref overloads aliasing it:

if (ref) providers.push({ provide: ref.token, useExisting: AGENT });

If two refs are provided at the same level:

providers: [
  provideAgent(TRIP_AGENT, { assistantId: 'trip' }),   // provides AGENT = trip
  provideAgent(HOTEL_AGENT, { assistantId: 'hotel' }),  // overwrites AGENT = hotel!
]

Angular's DI uses last-provided wins. Both TRIP_AGENT.token and HOTEL_AGENT.token end up resolving to the hotel agent — no error, silent wrong behavior. The fix (scope each to a separate component providers: []) is non-obvious from the API surface.

A @remarks on provideAgent noting this constraint, or an explicit runtime guard that throws when two refs are registered in the same scope, would prevent a confusing debug session.


4. CompatibleProps optional→required gap underdocumented at the call site

libs/chat/src/lib/client-tools/component-inputs.ts:39-41 contains a clear internal comment explaining that an optional schema prop is accepted against a required component input (the compile check passes, but the model may skip the prop, causing a broken mount at runtime). This is the correct trade-off, but the public JSDoc on view() and ask() in tools.ts does not mention it. A developer using z.string().optional() against input.required<string>() will see a green compile but get a runtime mount failure.

Adding a @remarks to view/ask surfacing this would be consistent with the "DX" goal of this PR — the warning should appear on hover.


Minor (not blocking)

  • events$ Subject is never completed (libs/ag-ui/src/lib/to-agent.ts:89): the Subject<AgentEvent> outlives the agent. This predates the PR but the new createAgentRef pattern increases the surface of consumers who might subscribe directly without takeUntil. Worth a @remarks on events$.

  • AgUiAgent<T> state typing is compile-time only: toAgent() always creates signal<Record<string, unknown>>({}) — the narrowed Signal<T> comes entirely from the as AgUiAgent<T> cast in injectAgent. Structurally valid in practice; differs from the LangGraph adapter's approach where the factory thread T through correctly. A brief note in injectAgent's JSDoc would set expectations.

  • Type-spec files import from zod/v4: If the project later supports Zod v3 schemas, the type-specs won't cover that path. Not an issue now.


Summary

Ship-blocking: none — core variance mechanics, schema↔input linkage, and DI plumbing are correct.
Worth fixing before merge: issue 1 (trivial one-liner) and issue 3 (add a @remarks at minimum).
Post-merge: issues 2 and 4 are real DX gaps on a PR whose goal is DX quality.

The required 'Vercel – threadplane' check never posted after repeated
branch updates (known Vercel-stall-on-rebase). Empty commit to retrigger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 1m 14s —— View job


PR Review — TypeScript DX Pass (Follow-up)

  • Gather context and understand PR scope
  • Read CLAUDE.md for repo guidelines
  • Read core changed files to check status of prior findings
  • Post updated findings

The latest synchronize event (fb1fc15 chore: retrigger Vercel preview deployment + c37f011 merge-from-main) introduced no new feature commits. The four issues flagged in prior reviews are still open. Summarizing current state:


1. Unnecessary as never cast — execute.ts:29 (still open — trivial fix)

// libs/chat/src/lib/client-tools/execute.ts:29
const value = await def.handler((v as { value: unknown }).value as never);

AnyFunctionToolDef.handler is (args: any) => unknown | Promise<unknown>any accepts unknown directly. The as never is misleading (never is the bottom type; no value can inhabit it). The as { value: unknown } cast on v is legitimately needed (discriminant narrowing under strictNullChecks: false), but the inner as never is not. Drop it:

const value = await def.handler((v as { value: unknown }).value);

Fix this →


2. Duplicate isAgentRef guard (still open — DX gap)

libs/langgraph/src/lib/agent.provider.ts:90 and libs/ag-ui/src/lib/provide-agent.ts:46 both define an identical duck-typed guard:

function isAgentRef<T>(x: unknown): x is AgentRef<T> {
  return typeof x === 'object' && x !== null && 'token' in x;
}

If AgentRef ever gains a second required field, only one copy may be updated, causing silent misidentification. The guard belongs in @threadplane/chat alongside AgentRef. Also: any object with a token property would pass this guard — AgentConfig has no token today, but the check is fragile.


3. Multiple AgentRefs in the same injector → silent last-wins collision (still open — worth a JSDoc @remarks)

Both adapters use a single AGENT token per injector, with refs aliasing it:

// agent.provider.ts:160 / provide-agent.ts:90
if (ref) providers.push({ provide: ref.token, useExisting: AGENT });

If two refs are provided at the same level:

providers: [
  provideAgent(TRIP_AGENT, { assistantId: 'trip' }),    // provides AGENT = trip
  provideAgent(HOTEL_AGENT, { assistantId: 'hotel' }),  // overwrites AGENT = hotel!
]

Angular's DI uses last-provided wins. Both TRIP_AGENT.token and HOTEL_AGENT.token resolve to the hotel agent — no error, silent wrong behaviour. A @remarks on provideAgent noting that each ref must be scoped to its own component providers: [] would prevent a confusing debug session.

Fix this →


4. CompatibleProps optional-schema-prop → required-input gap underdocumented (still open — DX gap)

libs/chat/src/lib/client-tools/component-inputs.ts:39-41 has a clear internal comment explaining the trade-off, but the public JSDoc on view() and ask() in tools.ts does not surface it. A developer using z.string().optional() against input.required<string>() will see a green compile but get a broken mount at runtime if the model skips the optional prop. Adding a @remarks line to @param component on both view and ask would make this visible on hover — consistent with the "strict-safe DX" goal of this PR.


Summary

Ship-blocking: none — core variance mechanics, schema↔input linkage, and DI plumbing are correct.
Worth fixing before merge: issues 1 (trivial one-liner) and 3 (add @remarks).
Post-merge or alongside: issues 2 (consolidate isAgentRef to @threadplane/chat) and 4 (surface the optional→required caveat in view/ask JSDoc).

@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 1m 46s —— View job


PR Review — TypeScript DX Pass (Synchronize)

  • Gather context and understand PR scope
  • Read CLAUDE.md for repo guidelines
  • Check new commits since last review
  • Verify status of prior findings (execute.ts, isAgentRef, multi-ref DI, optional→required gap)
  • Check for any new issues introduced
  • Post findings

The latest synchronize event is a merge from main (42baccd feat(demos): welcome-suggestion copy + palette validate-on-read + c2ec033). No new TypeScript DX feature commits. All four issues from prior reviews are still open.


1. Unnecessary as never cast — execute.ts:29 (still open — trivial fix)

// libs/chat/src/lib/client-tools/execute.ts:29
const value = await def.handler((v as { value: unknown }).value as never);

AnyFunctionToolDef.handler is (args: any) => unknown | Promise<unknown>any accepts unknown directly. The as never is misleading (never is the bottom type; no value can inhabit it). The outer as { value: unknown } cast on v is legitimately needed (discriminant narrowing under strictNullChecks: false), but the inner as never is not. Drop it:

const value = await def.handler((v as { value: unknown }).value);

Fix this →


2. Duplicate isAgentRef guard (still open — DX gap)

libs/langgraph/src/lib/agent.provider.ts:90 and libs/ag-ui/src/lib/provide-agent.ts:46 both define an identical duck-typed guard:

function isAgentRef<T>(x: unknown): x is AgentRef<T> {
  return typeof x === 'object' && x !== null && 'token' in x;
}

If AgentRef ever gains a second required field, only one copy may be updated, causing silent misidentification. The guard belongs in @threadplane/chat alongside AgentRef and re-exported. Also: any object with a token property would pass this guard — AgentConfig has no token today, but the check is fragile.


3. Multiple AgentRefs in the same injector → silent last-wins collision (still open — worth a JSDoc @remarks)

Both adapters use a single AGENT token per injector scope (agent.provider.ts:160, provide-agent.ts:90):

if (ref) providers.push({ provide: ref.token, useExisting: AGENT });

If two refs are provided at the same level:

providers: [
  provideAgent(TRIP_AGENT, { assistantId: 'trip' }),    // provides AGENT = trip
  provideAgent(HOTEL_AGENT, { assistantId: 'hotel' }),  // overwrites AGENT = hotel!
]

Angular's DI uses last-provided wins. Both TRIP_AGENT.token and HOTEL_AGENT.token resolve to the hotel agent — no error, silent wrong behaviour. A @remarks on provideAgent noting each ref must be scoped to its own component providers: [] would prevent a confusing debug session.

Fix this →


4. CompatibleProps optional→required gap underdocumented (still open — DX gap)

libs/chat/src/lib/client-tools/component-inputs.ts:39-41 has a clear internal comment explaining the trade-off: an optional schema prop (z.string().optional()) is accepted against a required component input — the compile check passes, but the model may skip the prop, causing a broken mount at runtime (caught by the schema-readiness gate). This is the correct design, but the public JSDoc on view() and ask() in tools.ts does not surface it. Adding a @remarks line to the @param component section on both functions would make this visible on hover — directly consistent with the "strict-safe DX" goal of this PR.


Summary

Ship-blocking: none — core variance mechanics (AnyFunctionToolDef bivariance hatch, AcceptComponent conditional, ComponentInputs<C> with any), schema↔input linkage, and DI plumbing are all correct.
Worth fixing before merge: issue 1 (trivial one-liner) and issue 3 (add @remarks to both provideAgent overloads).
Post-merge or alongside: issues 2 (consolidate isAgentRef to @threadplane/chat) and 4 (surface the optional→required caveat in view/ask JSDoc).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant